Payments Overview
The Patient Portal Payments API lets the authenticated patient list the payments (purchases) raised on their own cases. The endpoint is self-only: the JWT subject is the only patient whose records are returned, scoped to the cases owned by that patient in the calling organization.
Endpoints
| # | Method | Path | Purpose |
|---|---|---|---|
| 1 | GET | /api/v1/users/me/payments | List the patient's case payments (optionally filtered by case) |
Related resources: Orders (/me/orders) and Medications (/me/medications).
Authentication
Every endpoint requires a successful /verify-otp exchange first.
| Header | Required | Description |
|---|---|---|
cv-api-key | Yes | Tenant API key. Resolves the calling organization. Missing → 400 VALIDATION_ERROR. |
Authorization | Yes | Bearer <accessToken> from POST /api/v1/users/auth/verify-otp. Missing or malformed → 401. |
The patientPortalAuth() middleware enforces token type patient-portal, JWT/cv-api-key org-match, and that the user still exists. Any failure is collapsed to 401 VALIDATION_ERROR "Invalid or expired token".
Permission Matrix
| Action | Allowed when… |
|---|---|
| List own payments | Always (filtered to the patient's cases in the calling org). |
| List a specific case's payments | The case is owned by the patient (submitterId) and belongs to the calling org. Otherwise → 403. |
When caseId is omitted the server resolves the patient's own case ids in the calling organization first; if the patient has no cases, the response data array is [] and nextCursor is null.
Response Envelope
The list is wrapped under payments alongside the pagination cursor:
{
"status": 200,
"success": true,
"data": {
"payments": [ "..." ],
"nextCursor": "<id> | null"
}
}
Error responses follow:
{ "status": 400, "success": false, "error": "<message>", "code": "<CODE>" }
Query Parameters
| Field | Type | Required | Notes |
|---|---|---|---|
caseId | string (UUID) | No | Restrict the list to a single case owned by the patient. Verified via ensurePatientOwnsCase — if the case does not belong to the patient or to the calling org, returns 403. |
limit | integer | No | 1–100. Defaults to 20. Coerced from string. |
after | string (UUID) | No | Cursor — the last id from the previous page. The server skips that row and returns the next page. |
Pagination
Cursor-based over the row id, ordered by createdAt descending (newest first):
- Request page 1 without
after. The server returns up tolimititems plusnextCursor. - If
nextCursoris non-null, pass it asafter=<nextCursor>to fetch the next page. - When the server has no more rows,
nextCursorisnull.
The cursor is the last item's id. Internally the server takes limit + 1 rows, drops the extra, and emits its id as the cursor — so a null cursor unambiguously means "no more pages."
Object Shapes
Payment
Returned by GET /me/payments. Backed by CasePayment rows where isDeleted = false.
| Field | Type | Notes |
|---|---|---|
id | string (UUID) | CasePayment.id. |
description | string | Payment description (required on creation). |
amount | number | Charge amount before any discount. |
discountedAmount | number | null | Final amount after discounts, if applied. |
status | enum | One of PAID, UNPAID, CANCELED, IN_DISPUTE, LOST_DISPUTE, REFUND, ERROR, PENDING_RETRY. |
paymentDate | ISO-8601 datetime | null | When the payment settled. |
dueDate | ISO-8601 datetime | Invoice due date. |
caseId | string (UUID) | The case this payment belongs to. |
createdAt | ISO-8601 datetime | Server-generated. |
fees | { consultFee, convenienceFee, paymentProcessingFee, pharmacyFee, shippingFee } | null | Per-fee breakdown when a CasePaymentFees row exists; otherwise null. |
Server-Side Behaviors and Defaults
- Tenant + ownership scoping. With or without
caseId, the result is restricted to cases wheresubmitterId = userIdandorganizationId = req.patientOrganization.id. There is no cross-tenant or cross-patient surface. caseIdis pre-validated. When supplied,ensurePatientOwnsCaseruns before the list query; failure short-circuits to403.- Soft-deleted payments excluded. The query filters
isDeleted = false. - Default
limit.20. Maximum100. The validator coerces string → number. - Cursor semantics.
afteris the last row'sidfrom the previous page; the server usescursor: { id: after }, skip: 1and asks forlimit + 1rows to detect end-of-results. - Empty patient. If the patient has no cases in the calling org (and no
caseIdwas supplied), the endpoint returns{ payments: [], nextCursor: null }— no error.
Security Properties
- Tenant isolation. Case-id resolution pins
organizationIdto the calling org fromcv-api-key; the payment query is filtered to the case ids that resolution returns. - Ownership isolation. Case-id resolution pins
submitterIdto the JWT subject;caseIdqueries additionally pass throughensurePatientOwnsCase. - Uniform 403. "Doesn't exist", "not yours", and "wrong tenant" all collapse to the same
403 VALIDATION_ERROR"You do not have access to this case" so case ids cannot be probed. - Token type pinned. Only JWTs with
type: 'patient-portal'reach the handler. - Cross-tenant defense. The JWT's
organizationIdis verified against thecv-api-key-resolved org on every call. - No write surface. The endpoint is read-only — it never creates, captures, or refunds a payment.
Integrator Guidance
- Refresh proactively. Refresh the access token via
/refresh-tokenbefore the 15-minute expiry. - Listing strategy. Use
?caseId=when surfacing payments within a single-case view; omit it for an account-wide billing list. - Paginate forward only. The cursor moves forward through the sort order — there is no
beforecursor. - Show
discountedAmountwhen present. It is the amount actually charged;amountis the pre-discount figure. fees: nullis normal — it just means noCasePaymentFeesrow was written for that payment.- Treat
403as "no access, may or may not exist". Do not display case-id-specific debug text.